Skip to content

Shade the played span of the audio waveform as playback advances - #5826

Merged
lukemelia merged 7 commits into
mainfrom
cs-12575-audio-waveform-reflects-playback-position-with
Aug 20, 2026
Merged

Shade the played span of the audio waveform as playback advances#5826
lukemelia merged 7 commits into
mainfrom
cs-12575-audio-waveform-reflects-playback-position-with

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

In the reading formats (embedded/isolated), the default audio FileDef preview's waveform now reflects the native player's playback position, in the familiar audio-player idiom: the played portion renders in the full accent while the un-played remainder recedes to a dimmer shade, updating as the track plays and when the user seeks.

Waveform with played span highlighted

How it works

  • AudioPreview mirrors playback as a 0–1 ratio from the mounted <audio> element's timeupdate (plus seeking/emptied), preferring the element's own duration and falling back to the extracted duration before metadata arrives.
  • The played span is a second copy of the bars inside an SVG clipPath whose window width is the ratio, so partial-bar coverage is smooth rather than stepping bar-by-bar.
  • A track at rest keeps the waveform's usual full-strength look — the dimming class only applies once playback has begun.
  • Fitted cells mount no player, so their waveform is untouched.

Testing

  • New integration test in file-def-format-templates-test.gts: at rest nothing is marked played; after a timeupdate at 5s of a 10s track, the played layer exists and clips at half the waveform.
  • Full Integration | FileDef format templates module passes (17 tests, 61 assertions).
  • Verified live in the dev app against a 15-second PCM WAV.

Resolves CS-12575.

🤖 Generated with Claude Code

lukemelia and others added 3 commits August 19, 2026 18:41
In the reading formats, the waveform now mirrors the native player's
position: a clip-windowed copy of the bars tracks timeupdate from the
mounted audio element, so the played span keeps the accent at full
strength while the un-played remainder recedes. A track at rest keeps
the waveform's usual weight, and a fitted cell — which mounts no
player — is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 20m 36s ⏱️
4 364 tests 4 350 ✅ 14 💤 0 ❌
4 383 runs  4 369 ✅ 14 💤 0 ❌

Results for commit fe775a4.

Realm Server Test Results

    1 files      1 suites   14m 37s ⏱️
2 206 tests 2 206 ✅ 0 💤 0 ❌
2 289 runs  2 289 ✅ 0 💤 0 ❌

Results for commit fe775a4.

@lukemelia
lukemelia marked this pull request as ready for review August 20, 2026 14:50
@lukemelia
lukemelia requested review from a team and burieberry August 20, 2026 14:51

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖]

Reviewed this as the next owner of AudioPreview, focused on the one thing that has to be exactly right for a progress overlay to mean anything — that the clip boundary lands on the actual playback position — plus the duration-source fallback, and whether the change stays out of the paths it shouldn't touch (fitted cells, headless prerender).

Bottom line: no blocking issues. The design is correct and tightly scoped, and the load-bearing invariant holds for a real reason rather than by luck.

What lands right, mechanistically:

  • Clip boundary = playback position holds because the bars are uniformly time-spaced. waveBars lays each bar at x = index * (100 / n) across the 0–100 viewBox, and the envelope is a uniform resample of the whole track, so x is linear in time. playedWidth = ratio * 100 = (currentTime / duration) * 100 therefore names the x of the current instant, and a userSpaceOnUse clip rect from x=0 to playedWidth covers exactly the played span — partial last bar included. This is the invariant a future editor could silently break (e.g. switching to log-spaced or non-uniform bars); see the inline note on the clip rect.
  • Per-instance clipId is the right call. clipPath ids are document-global and the reading formats can mount several previews on one page, so the module clipSerial giving each instance its own url(#…) avoids cross-preview clip bleed. It's an instance field, so it's stable across re-renders.
  • Duration fallback + clamp is sound. Element duration wins once metadata arrives; Infinity (live streams) and NaN (pre-metadata / emptied) both fall through to the extracted figure, and the [0,1] clamp absorbs any transient extract-vs-media mismatch. See the inline note for the one intended degradation.
  • The paths that shouldn't change don't. Fitted mounts no player and keeps the un-suffixed .wave-svg markup, so its waveform is untouched; in a headless prerender hasPlayed is always false, so the overlay/<defs> never render and there's no clip-id in the prerendered HTML. And MidiPreview deliberately draws no player and no amplitude waveform, so there's no twin implementation this had to land on too.

Recommendation (non-blocking, follow-up): the new test pins only the extract-duration path at a fixed mid-track position. Consider also pinning the reset-to-rest transition (a seeking/emptied back to currentTime = 0 drops has-progress and removes the played layer) and the media-duration-wins branch — those are the two behaviors most likely to regress under a future refactor and neither is currently guarded. Detail in the inline thread on the test.

One clarification worth confirming rather than a change: the overlay is drawn after the base rects and painted at full fill-opacity over the 0.45 base, so the played region shows a single full-strength accent (no additive darkening from the dimmed base showing through). That's the intended read and it's correct as written.

</g>
<defs>
<clipPath id={{this.clipId}}>
<rect x='0' y='0' width={{this.playedWidth}} height='100' />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmation (non-blocking), and a guard-rail for the next editor.

This one clip rect is what makes the whole feature correct: width={{this.playedWidth}} = ratio * 100 marks the playback position only because the bars are uniformly time-spaced. waveBars places bar i at x = i * (100 / n) across the 0–100 viewBox, and the envelope is a uniform resample of the full track, so x is linear in playback time; with clipPathUnits defaulting to userSpaceOnUse, the 0 → playedWidth window covers exactly the played span, partial trailing bar included.

The fragility to flag: if the bar layout ever becomes non-uniform (log/mel spacing, silence-trimmed edges, a variable slot width), this mapping breaks silently — the highlight would drift from the true position with no test failing, because nothing here ties playedWidth back to the bar geometry. Nothing to change now; just the assumption to keep in mind whenever waveBars is touched.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Agreed, and the guard-rail is worth keeping visible. The new reset-to-rest and media-duration tests exercise playedWidth end-to-end but still don't tie it back to the bar geometry — they'd keep passing if waveBars moved to non-uniform spacing while the clip stayed linear. So this assumption remains the thing to re-check whenever waveBars changes; nothing to do on this PR.

Comment on lines +104 to +114
// The element's own duration wins once metadata arrives; before that (or
// in a context where the media never loads) the extracted figure stands in.
let duration =
Number.isFinite(el.duration) && el.duration > 0
? el.duration
: Number(this.args.model?.durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) {
this.playedRatio = 0;
return;
}
this.playedRatio = Math.max(0, Math.min(1, el.currentTime / duration));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmation (non-blocking).

The source ordering is right: el.duration wins once metadata loads, and both the pre-metadata NaN and the live-stream Infinity cases fail the Number.isFinite(...) && > 0 guard and fall through to the extracted durationSeconds. The final Math.max(0, Math.min(1, …)) absorbs the transient case where the extract duration disagrees slightly with the media's own before metadata arrives, so playedRatio can't escape [0,1].

The one intended degradation to be aware of: when neither source yields a positive finite duration, playedRatio is pinned to 0, so has-progress never applies and the overlay never renders — the track just plays with a static full-strength waveform. That's a reasonable fallback; noting it so it reads as deliberate rather than a missed case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmed. The new prefers the media element duration once metadata loads test now takes the el.duration-wins branch explicitly (finite duration of 20 over the extracted 10s), which the earlier test never did. The pinned-to-0 degradation when no source yields a positive finite duration is still deliberately unguarded — it's the intended static-waveform fallback, not a case worth a test.

Comment on lines +289 to +338
test('the audio waveform shades the played span as playback advances', async function (assert) {
let { AudioDef } = await loader.import<typeof AudioDefModule>(
`${baseRealm.url}audio-file-def`,
);
let { WaveformMetadataField } = await loader.import<
typeof MetadataFieldsModule
>(`${baseRealm.url}file-formats/metadata-fields`);

let audio = new AudioDef({
id: 'http://example.com/audio/take.wav',
url: 'http://example.com/audio/take.wav',
sourceUrl: 'http://example.com/audio/take.wav',
name: 'take.wav',
contentType: 'audio/wav',
contentSize: 2_646_078,
duration: 10,
waveform: new WaveformMetadataField({
decodeStatus: 'ok',
barsJson: JSON.stringify(Array.from({ length: 32 }, () => 0.5)),
barCount: 32,
}),
});

await renderCard(loader, audio, 'isolated');
assert
.dom('[data-test-audio-preview] .wave-svg')
.exists('the waveform renders');
assert
.dom('[data-test-audio-waveform-played]')
.doesNotExist('a track at rest marks nothing as played');

let player = find('[data-test-audio-player]') as HTMLAudioElement;
// No media loads in this environment (the src 404s), so the element's own
// currentTime/duration never become usable. Shadow currentTime with an own
// property so the handler reads a definite position and falls back to the
// extracted duration, independent of media state.
Object.defineProperty(player, 'currentTime', { value: 5 });
player.dispatchEvent(new Event('timeupdate'));
await settled();

assert
.dom('[data-test-audio-waveform-played]')
.exists('playback marks the played span');
assert
.dom('[data-test-audio-waveform-played]')
.hasAttribute(
'data-test-audio-waveform-played',
'50',
'5s into a 10s track clips the played layer at half the waveform',
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Test-coverage note (non-blocking, follow-up).

This pins the core well — the extract-duration fallback, the ratio math, and the clip window at a mid-track position all get exercised, and the at-rest negative case is asserted first. What it doesn't yet pin, and what's most likely to regress under a later refactor:

  • Reset to rest. After the played layer exists, a seeking/emptied with currentTime back at 0 should drop has-progress and remove [data-test-audio-waveform-played] again. Nothing guards that the overlay tears down, so a change that left playedRatio sticky wouldn't fail here.
  • The media-duration-wins branch. The test shadows currentTime and leans on the extract duration because no media loads; the el.duration-preferred path (finite, > 0) is never taken. A one-liner defining duration alongside currentTime would cover it.

Both are cheap add-ons in the same test style; fine as a follow-up rather than a blocker on this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Both branches are now pinned in ce37cdc2a4:

  • Reset to restthe audio waveform tears down the played span when playback resets to rest: after a timeupdate establishes the played layer, a seeking with currentTime back at 0 drives playedRatio to 0, and the test asserts [data-test-audio-waveform-played] no longer exists — so a sticky-ratio regression would fail here.
  • Media-duration-winsthe audio waveform prefers the media element duration once metadata loads: shadows the element with a finite duration of 20 alongside currentTime 5 and asserts the played layer clips at 25, not the 50 the extracted 10s would give — so it can only pass via the el.duration-preferred branch.

Kept in the same style as the existing test (own-property shadowing since no media loads); lint clean. Local full-suite run is currently blocked by the base realm not booting on :4201, so CI is the end-to-end check.

Add two integration tests alongside the existing played-span test: a
seek back to the start drops the overlay, and the media element's own
duration takes precedence over the extracted figure once metadata loads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Addressed the review's one follow-up (the test-coverage recommendation) in ce37cdc2a4: added the reset-to-rest and media-duration-wins tests, the two behaviors the summary flagged as most likely to regress under a later refactor. Replies are on the individual threads. Everything else in the review was confirmation, so no code changes — the design stands as reviewed.

@backspace backspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good

while exercising this I found that MP3s don’t have waveforms; I didn’t see an issue, Claude had a diagnosis

Image

I could open an issue with its further details?

An MP3 envelope bar is the RMS across its granules, but the bars were
scaled by the single loudest granule's amplitude. On the exponential
2^((global_gain - 210)/4) scale one dominant transient granule puts even
its own bar near 1/sqrt(granules per bar) of that peak, so every bar
collapsed toward zero and MP3 waveforms rendered as invisible slivers.
Scaling by the loudest bar restores full-scale waveforms, matching how
the WAV and decoded-PCM producers already behave (their raw RMS bars are
never peak-scaled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] @backspace — the diagnosis in your screenshot checked out exactly against the code, and it's now fixed on this branch in 37ea8aa, so no separate issue is needed.

What was wrong: extractMp3Envelope scaled the bars by envelope.peak, the amplitude of the single loudest granule, while each bar is the RMS across its granules. On the exponential 2^((global_gain − 210)/4) scale, one dominant transient granule leaves even its own bar near 1/√(granules per bar) of that peak, so every bar collapsed toward zero and the waveform rendered as invisible slivers. The uniform-gain fixtures in the existing tests are why it was never caught — with all granules equal, RMS equals the peak and the crush disappears.

The fix normalizes to the loudest bar instead (both the buffered and streaming extractors, via a shared helper), which matches how the WAV and decoded-PCM producers already behave — their raw RMS bars were never peak-scaled. There's a new regression test that plants a single transient frame among quiet ones and asserts the loudest bar still reaches full scale; the full Unit | audio metadata extractors module passes (94 tests).

One caveat: already-uploaded MP3s keep their persisted flat bars until their realm reindexes, since the waveform is extracted at indexing time.

@burieberry burieberry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Just a couple findings from claude:

packages/base/file-formats/audio-preview.gts

  • 98 [correctness] playedWidth quantizes to 0.1% of the track duration rather than of the rendered width, so on long media the clip window rounds to 0 while the dimming class is already applied.
  • 84 [correctness] playedRatio is never reset on a model change, and the emptied safety net only exists while the element is mounted.

packages/base/mp3-audio-def.gts

  • 89 [stale-comment] This comment (and the parallel one at packages/base/file-formats/file-view-model.ts:197) still says the MP3 envelope is normalized to the track's own peak — the behavior this PR replaced; only the three comments inside mp3-meta-extractor.ts were updated.

ylm and others added 2 commits August 20, 2026 16:52
…n comments

hasPlayed now keys off the rounded playedWidth rather than the raw ratio,
so the first moments of a very long track no longer dim the whole waveform
while the zero-width clip window highlights nothing.

Update the two sibling comments that still described the MP3 envelope as
normalized to the track's own peak (the loudest granule) to say the loudest
bar, matching the divisor the extractor now uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rm-reflects-playback-position-with

# Conflicts:
#	packages/host/tests/integration/components/file-def-format-templates-test.gts
@lukemelia
lukemelia merged commit 81678bd into main Aug 20, 2026
75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants